14   Zig 与 C 的互操作性

在本章中,我们将讨论 Zig 与 C 语言的互操作性。我们已经在9.11 节讨论了如何使用zig编译器构建 C 代码。但我们还没有讨论如何在 Zig 中实际使用 C 代码。换句话说,我们还没有讨论如何从 Zig 调用和使用 C 代码。

这是本章的主题。此外,在本书的下一个小项目中,我们将使用一个 C 库。因此,我们将在下一个项目中将这里讨论的很多知识付诸实践。

14.1如何从Zig调用C代码

与 C 语言的互操作性并不是什么新鲜事。大多数高级编程语言都具有 FFI(外部函数接口),可用于调用 C 代码。例如,Python 有 Cython,R 有.Call(),JavaScript 有ccall(),等等。但 Zig 与 C 语言的集成更深层次,这不仅影响 C 代码的调用方式,还影响 C 代码的编译方式以及如何将其合并到 Zig 项目中。

总而言之,Zig 与 C 具有很好的互操作性。如果您想从 Zig 调用任何 C 代码,则必须执行以下步骤:

  • 将 C 头文件导入到您的 Zig 代码中。
  • 将您的 Zig 代码与 C 库链接。

14.1.1导入 C 头文件的策略

在Zig中使用C代码总是涉及执行上面提到的两个步骤。然而,当我们具体讨论上面列出的第一步时,目前有两种不同的方法来执行这第一步,它们是:

  • 通过命令将C头文件翻译成Zig代码,zig translate-c然后导入使用翻译后的Zig代码。
  • @cImport()通过内置函数将 C 头文件直接导入到您的 Zig 模块中。

如果您不熟悉translate-c,这是编译器内部的一个子命令zig,它以 C 文件作为输入,并输出这些 C 文件中 C 代码的 Zig 表示形式。换句话说,这个子命令的工作原理类似于转译器。它接受 C 代码,并将其转换为等效的 Zig 代码。

我认为可以将其解释translate-c为一个生成 Zig 绑定到 C 代码的工具,类似于rust-bindgen1工具,该工具生成 Rust FFI 绑定到 C 代码。但这并不是对 的准确解释translate-c。该工具背后的想法是将 C 代码真正转换为 Zig 代码。

现在,从表面上看,@cImport()vstranslate-c似乎是两种完全不同的策略。但实际上,它们实际上是完全相同的策略。因为在底层,@cImport()内置函数只是 的快捷方式translate-c。这两种工具都使用相同的“C 到 Zig”转换功能。因此,当您使用 时@cImport(),实际上是在要求zig编译器将 C 头文件转换为 Zig 代码,然后将此 Zig 代码导入到您当前的 Zig 模块中。

目前,Zig 项目中有一个已接受的提案,即迁移@cImport()到 Zig 构建系统2。如果该提案得以实施,那么“使用@cImport()”策略将转变为“在 Zig 构建脚本中调用一个翻译 C 函数”。因此,将 C 代码转换为 Zig 代码的步骤将转移到 Zig 项目的构建脚本中,您只需将翻译后的 Zig 代码导入 Zig 模块即可开始从 Zig 调用 C 代码。

如果你仔细思考一下这个提议,你就会明白这其实只是一个小小的改变。我的意思是,逻辑是一样的,步骤也基本一样。唯一的区别是,其中一个步骤将被移到你的 Zig 项目的构建脚本中。

14.1.2将 Zig 代码与 C 库链接

无论您选择上一节中的哪一种策略,如果您想从 Zig 调用 C 代码,则必须将您的 Zig 代码与包含您要调用的 C 代码的 C 库链接起来。

换句话说,每次在 Zig 代码中使用 C 代码时,都会在构建过程中引入依赖项。对于任何有 C 和 C++ 使用经验的人来说,这应该不足为奇。因为在 C 语言中也一样。每次在 C 代码中使用 C 库时,也必须构建并将 C 代码与正在使用的 C 库链接起来。

当我们在 Zig 代码中使用 C 库时,zig编译器需要访问 Zig 代码中调用的 C 函数的定义。该库的 C 头文件提供了这些 C 函数的声明,但没有提供它们的定义。因此,为了访问这些定义,zig编译器需要构建 Zig 代码,并在构建过程中将其与 C 库链接。

正如我们在第 9 章中讨论的那样,将某些内容链接到库有不同的策略。这可能涉及先构建 C 库,然后将其与 Zig 代码链接。或者,如果此 C 库已在您的系统中构建并安装,则也可能仅涉及链接步骤。无论如何,如果您对此有任何疑问,请返回第 9 章

14.2导入 C 头文件

在第 14.1.1 节中,我们描述了目前有两种不同的路径可以将 C 头文件导入 Zig 模块,translate-c或者@cImport()。本节将分别更详细地描述每种策略。

14.2.1策略 1:使用translate-c

当我们选择此策略时,首先需要使用该translate-c工具将要使用的 C 头文件转换为 Zig 代码。例如,假设我们想使用C 头文件fopen()中的 C 函数stdio.h。我们可以stdio.h通过以下 bash 命令翻译 C 头文件:

zig translate-c /usr/include/stdio.h \
    -lc -I/usr/include \
    -D_NO_CRT_STDIO_INLINE=1 > c.zig \

请注意,在此 bash 命令中,我们传递了必要的编译器标志(-D用于定义宏、-l链接库、-I添加“包含路径”)来编译和使用stdio.h头文件。另请注意,我们将翻译过程的结果保存在名为 的 Zig 模块中c.zig

因此,运行此命令后,我们要做的就是导入此c.zig模块,然后开始调用您想要从中调用的 C 函数。下面的示例演示了这一点。记住我们在14.1.2 节中讨论的内容很重要。为了编译此示例,您必须通过向编译器libc传递标志来将此代码链接到。-lc``zig

const c = @import("c.zig");
pub fn main() !void {
    const x: f32 = 1772.94122;
    _ = c.printf("%.3f\n", x);
}
1772.941

14.2.2策略 2:使用@cImport()

要将 C 头文件导入到我们的 Zig 代码中,我们可以使用内置函数@cInclude()@cImport()。在@cImport()函数内部,我们打开一个块(带有一对花括号)。如果需要,我们可以在这个块中包含多个@cDefine()调用,以便在包含这个特定的 C 头文件时定义 C 宏。但在大多数情况下,您可能只需要在这个块中使用一个调用,即对 的调用@cInclude()

@cInclude()函数相当于#includeC语言中的语句。您提供要包含的C头文件的名称作为此@cInclude()函数的输入,然后结合@cImport()它将执行必要的步骤将此C头文件包含到您的Zig代码中。

您应该将 的结果绑定@cImport()到一个常量对象,就像对 所做的那样@import()。您只需将结果分配给 Zig 代码中的一个常量对象,这样,在 C 头文件中定义的所有 C 函数、C 结构、C 宏等都可以通过这个常量对象访问。

请看下面的代码示例,我们导入了标准 I/OC 库 ( stdio.h),并调用了C 函数printf()3。请注意,我们在此示例中还使用了 C 函数powf()4,它来自 C 数学库 ( )。为了编译此示例,您必须将标志和传递给编译器math.h,将此 Zig 代码与 C 标准库和 C 数学库链接起来。-lc``-lm``zig

const c = @cImport({
    @cDefine("_NO_CRT_STDIO_INLINE", "1");
    @cInclude("stdio.h");
    @cInclude("math.h");
});

pub fn main() !void {
    const x: f32 = 15.2;
    const y = c.powf(x, @as(f32, 2.6));
    _ = c.printf("%.3f\n", y);
}
1182.478

14.3关于将 Zig 值传递给 C 函数

Zig 对象与其 C 语言等效对象之间存在一些内在差异。最明显的差异可能是 C 字符串和 Zig 字符串之间的差异,我已在1.8 节中描述过。Zig 字符串是包含任意字节数组和长度值的对象。而 C 字符串通常只是一个指向以空字符结尾的任意字节数组的指针。

由于这些内在的差异,在某些特定情况下,在将 Zig 对象转换为 C 兼容值之前,您不能直接将 Zig 对象作为输入传递给 C 函数。但是,在其他一些情况下,您可以将 Zig 对象和 Zig 文字值直接作为输入传递给 C 函数,并且一切都会正常工作,因为zig编译器会为您处理所有事情。

因此,我们这里描述了两种不同的场景。我们称之为“自动转换”和“需要转换”。“自动转换”场景是指zig编译器为您处理所有事情,并自动将您的 Zig 对象/值转换为 C 兼容值。相比之下,“需要转换”场景是指您(程序员)有责任将该 Zig 对象转换为 C 兼容值,然后再将其传递给 C 代码。

这里没有描述第三种情况,即在 Zig 代码中创建一个 C 对象、C 结构体或 C 兼容值,并将此 C 对象/值作为输入传递给 Zig 代码中的 C 函数。这种情况将在后面的14.4 节中描述。​​在本节中,我们将重点介绍将 Zig 对象/值传递给 C 代码的场景,而不是将 C 对象/值传递给 C 代码的场景。

14.3.1 “自动转换”场景

“自动转换”场景是指zig编译器自动将我们的 Zig 对象转换为与 C 兼容的值。这种特定场景主要发生在两种情况下:

  • 带有字符串文字值;
  • 与第 1.5 节中介绍的任何原始数据类型。

当我们考虑上面描述的第二个实例时,zig编译器会自动将任何原始数据类型转换为它们的 C 等效类型,因为编译器知道如何正确地将 a 转换i16为 a signed short,或者将 au8转换为 a unsigned char,等等。现在,当我们考虑字符串文字值时,它们也可以自动转换为 C 字符串,特别是因为zig编译器不会强制将特定的 Zig 数据类型强制转换为字符串文字,除非您将此字符串文字存储到 Zig 对象中,并明确注释此对象的数据类型。

因此,使用字符串文字值,zig编译器可以更自由地推断在每种情况下应使用哪种数据类型。您可以说字符串文字值根据其使用的上下文“继承其数据类型”。大多数情况下,这种数据类型将是我们通常与 Zig 字符串关联的类型([]const u8)。但根据情况,它可能是不同的类型。当zig编译器检测到您正在提供字符串文字值作为某个 C 函数的输入时,编译器会自动将此字符串文字解释为 C 字符串值。

举个例子,请看下面公开的代码。这里我们使用fopen()C 函数来简单地打开和关闭一个文件。如果您不知道这个fopen()函数在 C 语言中是如何工作的,它需要两个 C 字符串作为输入。但在下面的代码示例中,我们将一些用 Zig 代码编写的字符串文字直接作为输入传递给这个fopen()C 函数。

换句话说,我们没有进行任何从 Zig 字符串到 C 字符串的转换。我们只是将 Zig 字符串字面量直接作为输入传递给 C 函数。而且它运行良好!因为编译器会"foo.txt"根据当前上下文将字符串解释为 C 字符串。

const c = @cImport({
    @cDefine("_NO_CRT_STDIO_INLINE", "1");
    @cInclude("stdio.h");
});

pub fn main() !void {
    const file = c.fopen("foo.txt", "rb");
    if (file == null) {
        @panic("Could not open file!");
    }
    if (c.fclose(file) != 0) {
        return error.CouldNotCloseFileDescriptor;
    }
}

让我们做一些实验,用不同的方式编写相同的代码,看看这会对程序产生什么影响。首先,我们将字符串存储"foo.txt"在一个 Zig 对象中,就像path下面的对象一样,然后将这个 Zig 对象作为输入传递给fopen()C 函数。

如果我们这样做,程序仍然可以编译并成功运行。请注意,在下面的示例中,我省略了大部分代码。这只是为了简洁起见,因为程序的其余部分仍然相同。此示例与上一个示例的唯一区别仅在于下面显示的这两行代码。

    const path = "foo.txt";
    const file = c.fopen(path, "rb");
    // Remainder of the program

现在,如果为对象指定显式数据类型会发生什么path?好吧,如果我通过使用数据类型注释zig该对象来强制编译器将此对象解释path为 Zig 字符串对象,那么实际上会收到编译错误,如下所示。我们之所以会收到此编译错误,是因为现在我强制编译器将其解释为 Zig 字符串对象。path``[]const u8``zig``path

根据错误消息,fopen()C 函数应该接收类型为[*c]const u8(C 字符串) 的输入值,而不是类型为[]const u8(Zig 字符串) 的值。更详细地说,该类型[*c]const u8实际上是 C 字符串的 Zig 类型表示。该类型的部分标识一个 C 指针。因此,这个 Zig 类型本质上意味着:一个指向常量字节[*c]数组 ( ) 的 C 指针。[*c]``const u8

    const path: []const u8 = "foo.txt";
    const file = c.fopen(path, "rb");
    // Remainder of the program
t.zig:2:7 error: expected type '[*c]const u8', found '[]const u8':
    const file = c.fopen(path, "rb");
                         ^~~~

因此,当我们专门讨论字符串文字值时,只要您不为这些字符串文字值提供明确的数据类型,zig编译器就应该能够根据需要自动将它们转换为 C 字符串。

但是,如果使用1.5 节中介绍的原始数据类型呢?我们以下面的代码为例。在这里,我们将一些浮点字面值作为 C 函数的输入powf()。请注意,此代码示例已成功编译并运行。

const std = @import("std");
const stdout = std.io.getStdOut().writer();
const cmath = @cImport({
    @cInclude("math.h");
});

pub fn main() !void {
    const y = cmath.powf(15.68, 2.32);
    try stdout.print("{d}\n", .{y});
}
593.2023

再次强调,由于zig编译器没有将特定的数据类型与字面值关联起来15.682.32乍一看,编译器可以在将这些值传递给 C 函数之前自动将其转换为相应的 C float(或)等效值。现在,即使我通过将这些字面值存储到 Zig 对象中并显式注释这些对象的类型,赋予它们显式的 Zig 数据类型,代码仍然可以编译并成功运行。double``powf()

    const x: f32 = 15.68;
    const y = cmath.powf(x, 2.32);
    // The remainder of the program
593.2023

14.3.2 “需求转换”场景

“需要转换”的情况是指我们需要手动将 Zig 对象转换为 C 兼容值,然后再将其作为输入传递给 C 函数。将 Zig 字符串对象传递给 C 函数时,就会遇到这种情况。

我们已经在上一个fopen()示例中看到了这种特定情况,该示例如下所示。您可以看到,在这个示例中,我们[]const u8path对象赋予了显式的 Zig 数据类型(),因此,我们强制zig编译器将此path对象视为 Zig 字符串对象。因此,我们现在需要在path将此对象传递给 之前手动将其转换为 C 字符串fopen()

    const path: []const u8 = "foo.txt";
    const file = c.fopen(path, "rb");
    // Remainder of the program
t.zig:10:26: error: expected type '[*c]const u8', found '[]const u8'
    const file = c.fopen(path, "rb");
                         ^~~~

将 Zig 字符串对象转换为 C 字符串有多种方法。解决此问题的一种方法是提供指向底层字节数组的指针,而不是直接提供 Zig 对象作为输入。您可以使用ptrZig 字符串对象的属性访问此指针。

path下面的代码示例演示了这一策略。请注意,通过属性传入指向底层数组的指针ptr,我们在使用 C 函数时不会出现编译错误fopen()

    const path: []const u8 = "foo.txt";
    const file = c.fopen(path.ptr, "rb");
    // Remainder of the program

此策略之所以有效,是因为指向属性中底层数组的指针在ptr语义上与指向字节数组的 C 指针(即 类型的 C 对象)相同*unsigned char。这就是为什么此选项也解决了将 Zig 字符串转换为 C 字符串的问题。

另一种选择是使用内置函数将 Zig 字符串对象显式转换为 C 指针@ptrCast()。使用此函数,我们可以将 类型的对象转换[]const u8为 类型的对象[*c]const u8。正如我在上一节中所述,[*c]类型的部分表示它是一个 C 指针。不推荐使用此策略。但它有助于演示 的用法@ptrCast()

@as()您可能还记得2.5 节@ptrCast()的内容。回顾一下,内置函数用于将 Zig 值从类型“x”显式转换(或强制转换)为类型“y”的值。但在本例中,我们转换的是指针对象。每次 Zig 中的某些“类型强制转换操作”中涉及指针时,都会涉及该函数。@as()``@ptrCast()

在下面的示例中,我们使用此函数将path对象转换为指向字节数组的 C 指针。然后,我们将此 C 指针作为输入传递给fopen()函数。请注意,此代码示例成功编译,没有任何错误。

    const path: []const u8 = "foo.txt";
    const c_path: [*c]const u8 = @ptrCast(path);
    const file = c.fopen(c_path, "rb");
    // Remainder of the program

14.4在Zig中创建C对象

在 Zig 代码中创建 C 对象,或者换句话说,创建 C 结构体的实例实际上相当容易。首先,您需要导入 C 头文件(如我在14.2 节中所述),该文件定义了您要在 Zig 代码中实例化的 C 结构体。之后,您只需在 Zig 代码中创建一个新对象,并使用 C 结构体的数据类型对其进行注释即可。

例如,假设我们有一个名为 的 C 头文件user.h,并且该头文件声明了一个名为 的新结构体User。该 C 头文件如下所示:

#include <stdint.h>

typedef struct {
    uint64_t id;
    char* name;
} User;

这个UserC 结构体有两个不同的字段,或者说两个结构体成员,分别名为idname。 字段id是一个无符号的 64 位整数值,而 字段name只是一个标准的 C 字符串。现在,假设我想在我的 Zig 代码中创建这个结构体的实例User。我可以通过将此user.h头文件导入到我的 Zig 代码中,并创建一个类型为 的新对象来实现User。这些步骤在下面的代码示例中重现。

undefined请注意,我在此示例中使用了关键字。这使我new_user无需为对象提供初始值即可创建对象。因此,与此new_user对象关联的底层内存未初始化,即,该内存当前填充的是“垃圾”值。因此,此表达式与 C 语言中的表达式具有相同的效果User new_user;,即“声明一个名为new_user类型的新对象User”。

new_user我们的责任是通过为 C 结构体的成员(或字段)赋值来正确地初始化与此对象关联的内存。在下面的例子中,我将整数 1 赋值给成员id。我还将字符串保存"pedropark99"到成员中name。请注意,在此示例中,我手动在为该字符串分配的数组末尾添加了一个空字符(零字节)。这个空字符在 C 语言中标志着数组的结束。

const std = @import("std");
const stdout = std.io.getStdOut().writer();
const c = @cImport({
    @cInclude("user.h");
});

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();

    var new_user: c.User = undefined;
    new_user.id = 1;
    var user_name = try allocator.alloc(u8, 12);
    defer allocator.free(user_name);
    @memcpy(user_name[0..(user_name.len - 1)], "pedropark99");
    user_name[user_name.len - 1] = 0;
    new_user.name = user_name.ptr;
}

因此,在上面的例子中,我们手动初始化了 C 结构体的每个字段。我们可以说,在这种情况下,我们正在“手动实例化 C 结构体对象”。但是,当我们在 Zig 代码中使用 C 库时,很少需要像这样手动实例化 C 结构体。只是因为 C 库通常在其公共 API 中提供了一个“构造函数”。因此,我们通常依赖这些构造函数来正确地初始化 C 结构体及其字段。

例如,考虑一下 Harfbuzz C 库。这是一个文本整形 C 库,它围绕“缓冲区对象”工作,或者更具体地说,是 C 结构体的实例hb_buffer_t。因此,如果我们想使用这个 C 库,我们需要创建这个 C 结构体的实例。幸运的是,这个库提供了函数hb_buffer_create(),我们可以使用它来创建这样的对象。因此,创建此类对象所需的 Zig 代码可能如下所示:

const c = @cImport({
    @cInclude("hb.h");
});
var buf: c.hb_buffer_t = c.hb_buffer_create();
// Do stuff with the "buffer object"

因此,我们不需要手动创建 C 结构体的实例hb_buffer_t,也不需要手动为该结构体中的每个字段赋值。因为构造函数hb_buffer_create()已经为我们完成了这项繁重的工作。

由于此buf对象以及new_user前面示例中的对象都是 C 结构体的实例,因此这些对象本身就是 C 兼容值。它们是在我们的 Zig 代码中定义的 C 对象。因此,您可以自由地将这些对象作为输入传递给任何期望接收此类 C 结构体作为输入的 C 函数。您无需使用任何特殊语法,也无需以任何特殊方式转换它们即可在 C 代码中使用它们。这就是我们在 Zig 代码中创建和使用 C 对象的方式。

14.5在 Zig 函数之间传递 C 结构

现在我们已经学习了如何在 Zig 代码中创建/声明 C 对象,接下来我们需要学习如何将这些 C 对象作为输入传递给 Zig 函数。正如我在第 14.4 节中所述,我们可以自由地将这些 C 对象作为输入传递给从 Zig 代码调用的 C 代码。但是,如何将这些 C 对象传递给 Zig 函数呢?

本质上,这种特定情况需要在 Zig 函数声明中进行一个小的调整。您需要做的就是确保_通过引用_将 C 对象传递给函数,而不是_通过值_传递。为此,您必须将接收此 C 对象的函数参数的数据类型注释为“指向 C 结构的指针”,而不是将其注释为“C 结构的实例”。

让我们考虑一下14.4 节中使用的 C 头文件User中的C 结构体。现在,假设我们要创建一个 Zig 函数来设置此 C 结构体中字段的值,就像下面声明的函数一样。请注意,此函数中的参数被注释为指向对象的指针 ( ) 。user.hid``set_user_id()``user``*``c.User

因此,在将 C 对象传递给 Zig 函数时,只需添加*接收 C 对象的函数参数的数据类型即可。这将确保 C 对象_通过引用_传递给函数。

因为我们已经将函数参数转换为指针,所以每次在函数体中访问此输入指针指向的值时,无论出于何种原因(例如,读取、更新或删除此值),都必须使用我们第六章.*学到的语法取消引用该指针。请注意,函数正在使用此语法来更改输入指针指向的结构体字段的值。set_user_id()``id``User

const std = @import("std");
const stdout = std.io.getStdOut().writer();
const c = @cImport({
    @cInclude("user.h");
});
fn set_user_id(id: u64, user: *c.User) void {
    user.*.id = id;
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();

    var new_user: c.User = undefined;
    new_user.id = 1;
    var user_name = try allocator.alloc(u8, 12);
    defer allocator.free(user_name);
    @memcpy(user_name[0..(user_name.len - 1)], "pedropark99");
    user_name[user_name.len - 1] = 0;
    new_user.name = user_name.ptr;

    set_user_id(25, &new_user);
    try stdout.print("New ID: {any}\n", .{new_user.id});
}
New ID: 25

  1. https://github.com/rust-lang/rust-bindgen ↩︎

  2. https://github.com/ziglang/zig/issues/20630 ↩︎

  3. https://cplusplus.com/reference/cstdio/printf/ ↩︎

  4. https://en.cppreference.com/w/c/numeric/math/pow ↩︎